Skip to content

Land governed agents, Slack, and homelab release - #42

Merged
jusso-dev merged 47 commits into
mainfrom
agent/issue33
Jul 27, 2026
Merged

Land governed agents, Slack, and homelab release#42
jusso-dev merged 47 commits into
mainfrom
agent/issue33

Conversation

@jusso-dev

@jusso-dev jusso-dev commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Outcome

Lands the combined core platform release:

  • governed agent runtime and direct-agent messaging
  • immutable homelab release flow
  • Slack OAuth, Socket Mode, identity mapping, agent exposure, durable delivery and cancellation
  • live read-only Slack grounding from Tawny hosts, Kelpie cases and UniFi client traffic

Runtime boundaries

Connector evidence is organisation-scoped, capability checked, encrypted at rest, persisted as query runs, audit logged, emitted through the outbox, bounded, and treated as untrusted evidence.

Validation

  • strict agent-gateway TypeScript check passes
  • combined workspace strict typecheck previously passed before the final focused gateway addition

Closes #19
Closes #33
Closes #40

Summary by CodeRabbit

  • New Features

    • Added governed agent harness APIs, CLI, MCP tools, run viewing, and organization-scoped authorization.
    • Added Slack integration with OAuth installation, signed events, identity mapping, exposure controls, Socket Mode, delivery diagnostics, and administration settings.
    • Added direct-message agent invocation with status replies and live connector research evidence.
    • Added synthetic cleanup maintenance workflows with approvals, verification, receipts, and object-deletion retries.
  • Bug Fixes

    • Archived records are now excluded from standard listings.
    • Readiness checks now report unavailable dependencies with HTTP 503 responses.
  • Documentation

    • Added guidance for Slack, immutable releases, homelab setup, audit verification, research feeds, and synthetic cleanup.

jusso-dev added 30 commits July 27, 2026 13:47
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jusso-dev, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ecb32ea4-3c31-4276-b8b6-54890ddbc896

📥 Commits

Reviewing files that changed from the base of the PR and between cb3ae0e and 5ea5a21.

📒 Files selected for processing (1)
  • .github/workflows/security.yml
📝 Walkthrough

Walkthrough

The PR adds organisation-scoped agent execution, direct-message replies, a governed agent harness with Slack adapters, synthetic cleanup workflows, audit/readiness tooling, archived-record filtering, connector support, and immutable release/homelab validation.

Changes

Governed runtime and harness

Layer / File(s) Summary
Gateway authorization and direct-message execution
apps/agent-gateway/..., apps/web/lib/agent-direct-message-domain.ts, apps/web/app/api/v1/rooms/...
Gateway calls now require bearer authentication and organisation headers; direct-room messages queue idempotent agent runs with scoped eligibility checks and terminal room projections.
Portable harness and Slack integration
packages/agent-harness/..., apps/web/app/api/v1/slack/..., apps/web/components/slack-settings-view.tsx
Adds governed HTTP, CLI, MCP, Slack OAuth, signed ingress, Socket Mode, delivery, administration, and operator UI flows.
Synthetic cleanup workflow
packages/database/src/synthetic-cleanup.ts, apps/web/lib/synthetic-cleanup-domain.ts, apps/worker/src/synthetic-cleanup-object.ts
Adds manifest-bound cleanup, approval, append-only receipts, object deletion attempts, retry authorization, and worker processing.
Audit, readiness, and storage
packages/audit/..., apps/web/lib/readiness.ts, packages/evidence/src/object-storage.ts
Adds detailed audit integrity outcomes, bounded readiness checks, archived visibility rules, and versioned signed object-storage operations.
Database, worker, and release support
packages/database/..., apps/worker/src/..., .github/workflows/..., scripts/...
Adds Slack and cleanup persistence, connector bootstrap, research terminal messages, immutable image validation, homelab installer hardening, and release gates.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

  • jusso-dev/Muster#21 — The PR implements governed agent runs, connector-backed integrations, Slack/portable harness access, homelab installation, and immutable release validation described by the issue.

Possibly related PRs

  • jusso-dev/Muster#41 — Shares the gateway bearer-token and organisation-scoped runtime changes, including direct-message execution and terminal reply projection.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately captures the main release focus on governed agents, Slack, and homelab work.
Linked Issues check ✅ Passed The diff covers the release, Slack harness, and direct-DM/synthetic-cleanup/audit work described in #19, #33, and #40.
Out of Scope Changes check ✅ Passed I don't see clear unrelated changes; the added docs, tests, UI, and infrastructure all support the stated release scope.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/issue33

Comment @coderabbitai help to get the list of available commands.

@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

Comment thread apps/web/app/api/v1/slack/lifecycle.integration.test.ts Fixed
Comment thread packages/agent-harness/src/index.ts Fixed
if (!payload || !signature || !secret)
throw new Error("Invalid Slack OAuth state");
const expected = createHmac("sha256", secret)
.update(payload)
@jusso-dev
jusso-dev merged commit 679bd82 into main Jul 27, 2026
4 of 8 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/agent-gateway/src/runtime.ts (1)

1706-1718: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

request() strips harness, so live connector evidence never executes.

PersistedRequest declares harness?.mode, and loadLiveConnectorEvidence returns early unless request.harness?.mode === "slack" (Line 2226-2230). But this Zod object has no harness key, and Zod strips unknown keys by default, so this.request(run).harness is always undefined for every caller — including loadAuthoritativeContext at Line 929-933. The entire Tawny/Kelpie/UniFi grounding path added in this PR is unreachable.

🐛 Proposed fix
         humanRequest: z.string().optional(),
         sourceMessageId: z.uuid().optional(),
         traceId: z.string().optional(),
+        harness: z
+          .object({
+            mode: z
+              .enum(["slack", "hermes", "mcp", "cli", "http"])
+              .optional(),
+          })
+          .optional(),
       })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/agent-gateway/src/runtime.ts` around lines 1706 - 1718, Update the Zod
schema inside request() to parse and preserve the harness field defined by
PersistedRequest, including its mode value, so this.request(run).harness?.mode
remains available to loadLiveConnectorEvidence and loadAuthoritativeContext.
Keep the existing validation and fallback behavior unchanged for other request
fields and invalid payloads.
apps/web/lib/connector-domain.ts (1)

74-97: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Archived connectors remain fully operable despite being hidden from list().

list() (Lines 46-49) now excludes archived integration records, but configure()'s existing-record lookup (Lines 74-87), queueQuery()'s integration lookup (Lines 245-257), and rotate()'s credential update (Lines 185-214) all still match by organisationId + id/product/instanceId with no archivedAt check. In practice:

  • configure() will silently "revive" an archived connector's row (setting status: "configured" again) without clearing archivedAt, leaving it hidden from the UI forever while actively updated.
  • queueQuery() will happily queue queries against a connector that's supposed to be archived/cleaned up.
  • rotate() will rotate credentials for an archived connector.

This undermines the governance intent behind archiving connector evidence (e.g. synthetic-cleanup).

🛡️ Proposed fix
       const [existing] = await tx
         .select({ id: schema.integrationRecords.id })
         .from(schema.integrationRecords)
         .where(
           and(
             eq(
               schema.integrationRecords.organisationId,
               subject.organisationId,
             ),
             eq(schema.integrationRecords.product, request.product),
             eq(schema.integrationRecords.instanceId, request.instanceId),
+            isNull(schema.integrationRecords.archivedAt),
           ),
         )
         .limit(1);
     const [integration] = await this.db
       .select({
         configuration: schema.integrationRecords.configuration,
         status: schema.integrationRecords.status,
       })
       .from(schema.integrationRecords)
       .where(
         and(
           eq(schema.integrationRecords.organisationId, subject.organisationId),
           eq(schema.integrationRecords.id, integrationId),
+          isNull(schema.integrationRecords.archivedAt),
         ),
       )
       .limit(1);

rotate() will additionally need a join or a preceding existence check against integrationRecords filtered on isNull(archivedAt) before updating credentials.

Also applies to: 238-264, 185-236

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/connector-domain.ts` around lines 74 - 97, Prevent archived
integration records from being used by configure(), queueQuery(), and rotate().
Add an isNull(archivedAt) predicate to the existing-record lookups in
configure() and queueQuery(), and make rotate() verify the associated
integrationRecords row is unarchived before updating credentials; preserve the
existing not-found behavior when no active record exists.
🟠 Major comments (29)
packages/database/migrations/0020_ancient_carnage.sql-81-96 (1)

81-96: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Enforce organisation affinity for Slack references.

These foreign keys validate each ID independently, so rows can mix an organisation_id from tenant A with an installation, agent, actor, run, or inbox event from tenant B. For example, a cross-tenant slack_run_deliveries row could route a tenant B run through tenant A’s Slack installation. Use composite (organisation_id, id) keys and composite foreign keys (or equivalent database-enforced validation) for these relationships.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/migrations/0020_ancient_carnage.sql` around lines 81 - 96,
Update the Slack-related schema and migration constraints for
slack_agent_exposures, slack_identity_mappings, slack_inbox_events,
slack_installations, and slack_run_deliveries so referenced entities are
validated by composite (organisation_id, id) keys rather than independent IDs.
Add matching composite unique keys where needed and replace the listed
single-column foreign keys with composite foreign keys that enforce the same
organisation across installations, agents, actors, runs, and inbox events.
apps/web/app/api/v1/slack/events/route.ts-24-29 (1)

24-29: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Swallowed recordEvent failure is unobservable and acks Slack retries.

Returning ok: true after an exception tells Slack the event was accepted, so its retry budget is consumed while nothing durable was written (a thrown transaction rolls back rather than recording health). Distinguish expected cases (unmapped workspace/duplicate) from unexpected ones, and at minimum emit a structured log so silent event loss is detectable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/api/v1/slack/events/route.ts` around lines 24 - 29, Update the
error handling around SlackGovernanceAdapter.recordEvent to distinguish expected
unmapped-workspace or duplicate-event failures from unexpected failures.
Preserve acknowledgment for expected cases, but log unexpected errors with
structured context before returning, ensuring transaction failures are
observable without exposing details to Slack.
.env.example-33-35 (1)

33-35: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Placeholder token passes the min(32) validation.

apps/agent-gateway/src/index.ts only enforces z.string().min(32), and this 37-char placeholder satisfies it, so an unedited .env copy yields a working gateway with a publicly known bearer token. Consider a shorter, obviously invalid placeholder (or empty value) so startup fails loudly.

🔒 Suggested change
-MUSTER_AGENT_GATEWAY_TOKEN=replace-with-at-least-32-random-bytes
+# Generate with: openssl rand -hex 32
+MUSTER_AGENT_GATEWAY_TOKEN=
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example around lines 33 - 35, Replace the MUSTER_AGENT_GATEWAY_TOKEN
placeholder in the environment example with a shorter, clearly invalid value
that cannot satisfy the current 32-character minimum validation, while
preserving the variable and surrounding configuration.
apps/web/lib/agent-learning-domain.ts-831-907 (1)

831-907: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Direct-message reply projection is duplicated from the gateway.

This block reproduces projectDirectMessageTerminalReply in apps/agent-gateway/src/runtime.ts:86-207 — same source-message/room/membership guards, same document shape, and the same agent-direct-message-reply:${run.id} / room.message.created:agent-direct-message:${run.id} keys. The two will drift. Consider hoisting the projection into a shared package (e.g. @muster/database or a shared domain module) consumed by both.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/agent-learning-domain.ts` around lines 831 - 907, Extract the
duplicated direct-message terminal reply projection into a shared
domain/database helper, then replace this inline transaction logic and gateway
implementation in projectDirectMessageTerminalReply with that helper. Preserve
the existing source-message, room, membership, document, idempotency-key, and
outbox behavior while ensuring both callers use the same implementation.
apps/web/app/api/v1/slack/commands/route.ts-22-39 (1)

22-39: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Silent catch drops the command with no signal to operator or user.

Every failure from recordEvent — including "Slack workspace is not connected" and encryption-key errors — is swallowed with no log, and the user is still told "Muster accepted your request." Nothing durable was written in that path (the outbox write happens inside recordEvent), so there is no retry either. At minimum log the failure and differentiate the acknowledgement text.

🐛 Proposed fix
   try {
     await new SlackGovernanceAdapter().recordEvent(rawBody, {
       ...
     });
-  } catch {
-    // The signed request is acknowledged within Slack's deadline; outbox retry
-    // and installation health hold the durable failure path.
-  }
-  return Response.json({ response_type: "ephemeral", text: "Muster accepted your request." });
+  } catch (error) {
+    console.error(
+      JSON.stringify({
+        level: "error",
+        event: "slack.command.record_failed",
+        teamId,
+        error: error instanceof Error ? error.message : "unknown",
+      }),
+    );
+    return Response.json({
+      response_type: "ephemeral",
+      text: "Muster could not accept your request. Contact an operator if this continues.",
+    });
+  }
+  return Response.json({
+    response_type: "ephemeral",
+    text: "Muster accepted your request.",
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/api/v1/slack/commands/route.ts` around lines 22 - 39, Update the
error handling around SlackGovernanceAdapter.recordEvent to log the caught
failure with sufficient context for operators, and track whether recording
succeeded. Return the existing “Muster accepted your request.” acknowledgement
only on success; when recordEvent fails, return a distinct ephemeral response
indicating the request could not be recorded instead of claiming acceptance.
apps/web/app/api/v1/agent-harness/invocations/route.ts-8-15 (1)

8-15: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Namespace the client-supplied idempotency key.

The raw header value is written straight into agentRuns.idempotencyKey, which is a shared namespace within an organisation (e.g. parker-agent-run:* in apps/web/lib/parker-report-domain.ts, integration:*). A caller can pass a key that collides with a run created by another producer; GovernedAgentHarness.invoke (packages/agent-harness/src/index.ts:155-316) would then resolve that unrelated run and return it as a duplicate. Prefix the key and bound its length.

🛡️ Proposed fix
-    const idempotencyKey = request.headers.get("idempotency-key")?.trim();
-    if (!idempotencyKey)
+    const suppliedKey = request.headers.get("idempotency-key")?.trim();
+    if (!suppliedKey || suppliedKey.length > 160)
       throw new Error("Idempotency-Key header is required for harness invocations");
+    const idempotencyKey = `agent-harness:${suppliedKey}`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/api/v1/agent-harness/invocations/route.ts` around lines 8 - 15,
Update the idempotency key handling in the route before calling
GovernedAgentHarness.invoke: namespace the trimmed client-supplied value with a
harness-specific prefix and enforce the supported maximum length. Pass only this
namespaced, bounded key to invoke so it cannot collide with keys from other
producers, while preserving the required-header validation.
apps/agent-gateway/src/runtime.ts-1218-1252 (1)

1218-1252: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Heartbeat progress writes need a unique or conflict-tolerant idempotency key

outbox_events.idempotency_key is uniquely indexed, and this heartbeat reuses agent.run.progress:${run.id}:executing on every interval. The second renewal will reject inside the transaction, and the interval callback does not catch that rejection, so lease renewal stops for an otherwise healthy run.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/agent-gateway/src/runtime.ts` around lines 1218 - 1252, Update the
heartbeat transaction in the lease-renewal method containing the agentRuns
update and writeOutbox call so each progress event uses a unique or
conflict-tolerant idempotencyKey instead of reusing
`agent.run.progress:${run.id}:executing`. Preserve the existing outbox payload
and progress behavior while ensuring repeated heartbeat intervals cannot reject
the transaction due to the unique `outbox_events.idempotency_key` constraint.
apps/web/app/api/v1/slack/oauth/callback/route.ts-10-44 (1)

10-44: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Callback authorises solely from the signed state; no session binding.

The subject is reconstructed from state alone, so anyone who obtains the callback URL (browser history, referrer, logs, shared link) within the 10-minute window can complete the installation as the encoded actor. consumeOAuthState prevents replay after the first use, but not first use by a third party. Consider also verifying the current session matches verified.actorId/organisationId when a session is present, and falling back to failure otherwise.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/api/v1/slack/oauth/callback/route.ts` around lines 10 - 44, Bind
the Slack OAuth callback to the authenticated session before authorising the
installation: after verifySlackOAuthState in GET, retrieve the current session
and require it to identify the same actor and organisation as verified.actorId
and verified.organisationId. Reject the callback when no session exists or the
session does not match, then continue constructing the subject and calling
consumeOAuthState only after this validation.
packages/agent-harness/src/index.ts-1377-1382 (1)

1377-1382: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

No atomic claim on the inbox event; concurrent workers can double-post.

The status check is a read followed by a much later write (TOCTOU). Two workers handed the same inboxEventId (outbox retry + Socket Mode reconnect) both pass this guard. The harness idempotencyKey prevents a second agent run, but chat.postMessage at Line 1713 runs in both paths, so the user sees duplicate acknowledgements. Claim the row atomically first, e.g. UPDATE slack_inbox_events SET status='processing' WHERE id = ? AND status IN ('pending','failed') RETURNING *, and bail when no row is returned.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/agent-harness/src/index.ts` around lines 1377 - 1382, Replace the
read-only status guard in the inbox event handling flow with an atomic claim
using the event row’s update operation: transition the matching slack inbox
event to processing only when its current status is pending or failed, and
retrieve the claimed row. Return immediately when no row is claimed, then use
the returned row for subsequent processing so concurrent workers cannot both
reach chat.postMessage.
packages/agent-harness/src/index.ts-411-423 (1)

411-423: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

State replay guard can be bypassed by appending an extra dot-segment.

value.split(".") keeps only the first two parts, so payload.signature.junk verifies successfully, while consumeOAuthState hashes the whole raw string — producing a different idempotency key each time. A caller can therefore reuse one valid state indefinitely (within TTL) despite the single-use record. Reject anything that isn't exactly two segments.

🔒 Proposed fix
-  const [payload, signature] = value.split(".");
+  const parts = value.split(".");
+  const [payload, signature] = parts;
   const secret = process.env.SLACK_OAUTH_STATE_SECRET;
-  if (!payload || !signature || !secret)
+  if (parts.length !== 2 || !payload || !signature || !secret)
     throw new Error("Invalid Slack OAuth state");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/agent-harness/src/index.ts` around lines 411 - 423, Update
verifySlackOAuthState to reject values that do not contain exactly one dot and
therefore exactly two non-empty segments before validating the signature. Ensure
the payload and signature used for HMAC verification match the complete raw
state consumed by consumeOAuthState, while preserving the existing invalid-state
error behavior.
apps/web/app/api/v1/slack/interactions/route.ts-18-24 (1)

18-24: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Silently swallowed errors make dropped interactions invisible.

If recordEvent throws before the inbox insert (unknown/inactive workspace, encryption or transaction failure), nothing is persisted — so the "durable inbox carries retries" claim doesn't hold for this path, and there is no log or metric to detect it. Keep the fast 200 to Slack, but emit a structured error log.

🔭 Proposed fix
   try {
     const payload = JSON.parse(encoded) as Record<string, unknown>;
     await new SlackGovernanceAdapter().recordEvent(rawBody, payload);
-  } catch {
+  } catch (error) {
     // Slack requires a fast acknowledgement. The durable inbox carries retries
     // and operator-visible delivery health without exposing internals to Slack.
+    console.error(
+      JSON.stringify({
+        level: "error",
+        event: "slack.interaction.record_failed",
+        error: error instanceof Error ? error.message : "unknown",
+      }),
+    );
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/api/v1/slack/interactions/route.ts` around lines 18 - 24, Update
the catch block in the Slack interaction handler around
SlackGovernanceAdapter.recordEvent to emit a structured error log containing the
caught error and relevant interaction context, while preserving the immediate
successful acknowledgement to Slack and existing behavior for JSON parsing and
event recording.
apps/web/app/api/v1/ready/route.ts-1-16 (1)

1-16: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move readinessResponse out of route.ts Next route files should only export handlers/config, so this extra named export can fail route export validation. Put it in apps/web/lib/readiness.ts and import it from the route and test (prefer @/lib/readiness).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/api/v1/ready/route.ts` around lines 1 - 16, Move the
readinessResponse function from the route module into the readiness library,
then export and reuse it there. Update the GET handler in the route and any
tests to import readinessResponse from the library, preferably through the
`@/lib/readiness` alias, leaving the route to export only configuration and
handlers.
packages/agent-harness/src/slack-socket.ts-40-75 (1)

40-75: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle Socket Mode control frames before envelope validation. Slack sends hello and disconnect messages without envelope_id, so parseSocketMessage will reject them, triggering error handling on connect and ignoring the reconnect hint on disconnect.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/agent-harness/src/slack-socket.ts` around lines 40 - 75, Update
handleSlackSocketMessage and parseSocketMessage to recognize Slack Socket Mode
control frames before requiring an envelope_id or payload. Handle hello normally
without recording or acknowledging, and handle disconnect by preserving its
reconnect hint through the existing socket lifecycle; keep envelope validation,
recording, and acknowledgement unchanged for regular event envelopes.
.github/workflows/ci.yml-128-128 (1)

128-128: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

actions/checkout doesn't disable credential persistence.

Both the container (line 128) and promote (line 256) jobs check out the repo without persist-credentials: false, while holding packages: write/attestations: write tokens for the rest of the job (including docker build on the checked-out context). zizmor flags this as credential persistence via artifacts (artipacked); the persisted token remains in .git/config for the whole job.

🔒 Proposed fix
       - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+        with:
+          persist-credentials: false

Apply to both the container job (line 128) and promote job (line 256).

Also applies to: 256-256

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml at line 128, Update the actions/checkout steps in
both the container and promote jobs to disable credential persistence by
configuring persist-credentials to false, while preserving their existing pinned
action references and job behavior.

Source: Linters/SAST tools

.github/workflows/ci.yml-151-173 (1)

151-173: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Image is pushed to GHCR before the vulnerability scan can block it.

On push events, docker/build-push-action runs with push: true (lines 151-166) and publishes the staging-* tag to GHCR immediately. The Trivy scan (167-173) only runs afterward, scanning the already-published image. Since GHCR visibility is set once at the package level (per this PR's README changes), a HIGH/CRITICAL-vulnerable image is briefly publicly pullable before the job fails and blocks promotion.

Consider building with push: false, load: true first, scanning the local image, and only pushing (or re-running the push step) once Trivy passes — mirroring what's already done for pull_request builds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 151 - 173, Update the build-and-scan
flow around the docker/build-push-action step and “Scan built image with Trivy”
step so push events build and load the image locally without publishing it
first. Run Trivy against that local image, then add or reuse a subsequent push
step that publishes the image only after the scan succeeds; preserve the
existing pull_request behavior and image tag.
scripts/install-homelab.sh-209-217 (1)

209-217: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Abort the gateway readiness probe after a short timeout. fetch("http://127.0.0.1:3002/ready") can hang indefinitely and block the installer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install-homelab.sh` around lines 209 - 217, Update the
gateway_authentication readiness probe to enforce a short timeout on the fetch
request to http://127.0.0.1:3002/ready, using the existing Node.js execution
block and preserving its current authenticated/authentication_required output
and failure handling.
packages/database/src/bootstrap-connectors.ts-104-105 (1)

104-105: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Canonicalize JSON before comparing connector configuration.

PostgreSQL jsonb reorders object keys, so JSON.stringify(existing.record.configuration) can differ from the newly built but equivalent publicConfiguration. This makes every bootstrap appear changed, producing needless updates, audit events, and outbox work on each startup.

Proposed fix
-function sameJson(left: unknown, right: unknown) {
-  return JSON.stringify(left) === JSON.stringify(right);
+function canonicalJson(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(canonicalJson);
+  if (value && typeof value === "object")
+    return Object.fromEntries(
+      Object.entries(value)
+        .sort(([left], [right]) => left.localeCompare(right))
+        .map(([key, item]) => [key, canonicalJson(item)]),
+    );
+  return value;
+}
+
+function sameJson(left: unknown, right: unknown) {
+  return (
+    JSON.stringify(canonicalJson(left)) ===
+    JSON.stringify(canonicalJson(right))
+  );
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/src/bootstrap-connectors.ts` around lines 104 - 105, Update
sameJson to canonicalize both JSON values before comparing them, using
deterministic object-key ordering while preserving array order and nested
structures. Ensure equivalent configurations compare equal even when key
insertion order differs, so bootstrap avoids unnecessary updates and related
side effects.
packages/database/migrations/meta/0018_snapshot.json-10280-10290 (1)

10280-10290: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Use a UUID foreign key for task agent runs.

tasks.agent_run_id is text, while agent_runs.id is uuid, and no foreign key connects them. This permits malformed or orphaned references and can break worker/status joins. Change the column to uuid and add the appropriate organisation-scoped foreign key.

Also applies to: 1106-1116, 1423-1436

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/migrations/meta/0018_snapshot.json` around lines 10280 -
10290, Update the task schema metadata for agent_run_id in the affected snapshot
sections to use uuid instead of text, and add the organisation-scoped foreign
key referencing agent_runs.id. Ensure the corresponding snapshot entries at the
other affected locations remain consistent with the updated column type and
relationship.
packages/database/migrations/meta/0018_snapshot.json-1118-1122 (1)

1118-1122: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Enforce organisation equality in foreign keys.

These rows store organisation_id, but the foreign keys validate only the referenced UUID. A row in organisation A can therefore reference an agent, integration, or related record belonging to organisation B. Add composite (organisation_id, id) references or an equivalent database-level invariant for all organisation-scoped relationships.

Also applies to: 1423-1449, 5232-5248, 5391-5443

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/migrations/meta/0018_snapshot.json` around lines 1118 -
1122, Update the organisation-scoped foreign-key definitions represented in
snapshot 0018, including the additional referenced ranges, so each relationship
validates the composite (organisation_id, id) pair rather than only the related
UUID. Apply this consistently to agent, integration, and related
organisation-scoped records while preserving existing non-scoped foreign keys.
packages/database/migrations/meta/0018_snapshot.json-3533-3537 (1)

3533-3537: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Add foreign keys for approval references.

decisions.approval_id and reaction_pack_revisions.approval_id are UUID references but have no FK to approvals.id. Invalid or deleted approval IDs can therefore be persisted, weakening governance provenance. Add the foreign keys and enforce organisation consistency.

Also applies to: 3570-3610, 7559-7563, 7660-7712

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/migrations/meta/0018_snapshot.json` around lines 3533 -
3537, Add foreign-key constraints for decisions.approval_id and
reaction_pack_revisions.approval_id referencing approvals.id, and enforce
matching organisation IDs through the existing organisation-consistency
constraint pattern. Update all corresponding schema and migration metadata
entries, including the approval reference definitions and table foreign-key
sections, while preserving the UUID and nullable properties.
packages/database/migrations/meta/0019_snapshot.json-315-341 (1)

315-341: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Enforce organisation consistency in foreign keys.

These child tables carry organisation_id, but their foreign keys validate only the referenced UUID. PostgreSQL therefore permits, for example, an agent_definitions row in organisation A to reference an actor from organisation B, breaking tenant isolation. Add composite (organisation_id, id) references and composite foreign keys across these relationships.

Also applies to: 488-540, 1423-1488, 1801-1853, 9348-9400

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/migrations/meta/0019_snapshot.json` around lines 315 - 341,
Update the migration snapshot foreign-key definitions for agent_definitions and
the other referenced ranges to enforce tenant consistency: include
organisation_id alongside the referenced id in each parent key, and define child
foreign keys over the corresponding (organisation_id, id) column pairs. Apply
this consistently to every relationship identified by the comment, preserving
the existing constraint names and delete/update behavior.
packages/database/migrations/meta/0020_snapshot.json-7128-7142 (1)

7128-7142: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Scope outbox idempotency by organisation.

outbox_idempotency_unique is globally unique on idempotency_key, unlike the organisation-scoped idempotency constraints used elsewhere in this snapshot. Identical client keys from different organisations will collide and one tenant can prevent another tenant’s event from being persisted.

Change the source index to (organisation_id, idempotency_key) and regenerate the snapshot.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/migrations/meta/0020_snapshot.json` around lines 7128 -
7142, The outbox_idempotency_unique index is missing organisation scoping.
Update its source definition to use the composite columns organisation_id and
idempotency_key, then regenerate the database snapshot so the index metadata
reflects the new scoped uniqueness.
packages/database/migrations/meta/0020_snapshot.json-11046-11054 (1)

11046-11054: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use canonical table identifiers in the provenance constraint.

The check allows researchWatchlists, reportManifests, reportSchedules, and agentMemories, while the snapshot defines the corresponding tables as research_watchlists, report_manifests, report_schedules, and agent_memories. Cleanup manifests using the actual table names will be rejected.

Normalize these values in the source schema/migration and regenerate the snapshot.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/migrations/meta/0020_snapshot.json` around lines 11046 -
11054, Update the synthetic_artifact_provenance artifact-table constraint in the
source schema/migration to use the canonical snake_case identifiers
research_watchlists, report_manifests, report_schedules, and agent_memories,
then regenerate the corresponding snapshot so its
synthetic_artifact_provenance_table_check matches the source.
packages/database/migrations/meta/0020_snapshot.json-315-341 (1)

315-341: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Enforce organisation-aware foreign keys.

These relationships constrain only the referenced UUID, not the row’s organisation_id. A row in organisation A can therefore reference an actor, agent, or Slack installation belonging to organisation B, undermining tenant isolation and allowing inconsistent joins.

Add composite organisation-aware foreign keys (with matching parent uniqueness) or an equivalent database-level constraint in the source schema, then regenerate this snapshot. Apply the same audit to all organisation-scoped relationships.

Also applies to: 1423-1488, 10150-10202, 10315-10367

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/migrations/meta/0020_snapshot.json` around lines 315 - 341,
Update the source schema for organisation-scoped relationships so foreign keys
validate both the referenced UUID and organisation_id, using matching composite
parent uniqueness or an equivalent database constraint. Apply this to the
agent_definitions relationships and the additional actor, agent, and Slack
installation relationships covered by the review. Regenerate the migration
snapshot after updating the schema, preserving tenant isolation across all such
joins.
deploy/docker/docker-compose.homelab.yml-141-146 (1)

141-146: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Hardcoded unifi.local IP has no override; will misroute for most homelab networks.

Both worker and agent-gateway pin extra_hosts: ["unifi.local:192.168.1.1"] with no corresponding environment variable in .env.homelab.example (only UNIFI_BASE_URL/UNIFI_API_KEY/etc. are provided, none of which control this mapping). 192.168.1.1 is a common default LAN gateway address but not universal — any operator whose UniFi controller/gateway sits at a different address gets this DNS override silently pointing at the wrong host, breaking the UniFi grounding connector by default.

Parameterize this via a new env var with a documented default so operators can override it without hand-editing the compose file.

🛠️ Suggested fix
   worker:
     <<: *muster-service
     extra_hosts:
-      - "unifi.local:192.168.1.1"
+      - "unifi.local:${UNIFI_HOST_IP:-192.168.1.1}"
   agent-gateway:
     <<: *muster-service
     extra_hosts:
-      - "unifi.local:192.168.1.1"
+      - "unifi.local:${UNIFI_HOST_IP:-192.168.1.1}"

Add UNIFI_HOST_IP= (with a comment on how to find the correct address) to deploy/docker/.env.homelab.example.

Also applies to: 165-170

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/docker/docker-compose.homelab.yml` around lines 141 - 146,
Parameterize the hardcoded unifi.local mapping in both the worker and
agent-gateway extra_hosts entries using a new UNIFI_HOST_IP environment
variable, preserving a documented default fallback. Add UNIFI_HOST_IP to
deploy/docker/.env.homelab.example with a comment explaining how operators can
determine the correct UniFi host address.
deploy/docker/.env.homelab.example-26-26 (1)

26-26: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

SLACK_REDIRECT_URI should default to an https:// URL

Slack rejects http:// redirect URLs, so this placeholder will make the OAuth flow fail if copied as-is. Use an https:// example and note that it must be served behind an HTTPS reverse proxy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/docker/.env.homelab.example` at line 26, Update the SLACK_REDIRECT_URI
placeholder to use an https:// URL instead of http://, and add a concise note
that the endpoint must be served behind an HTTPS reverse proxy.
packages/evidence/src/object-storage.ts-118-126 (1)

118-126: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Silent fallback to hard-coded development credentials.

OBJECT_STORAGE_ACCESS_KEY/OBJECT_STORAGE_SECRET_KEY default to muster / local-minio-secret and the endpoint to loopback. In a misconfigured deployment this does not fail fast — it authenticates with a credential literal that is committed to the repository, and failures surface later as opaque 403s. Given this package is used by the immutable homelab release path, require the credentials outside an explicitly local profile.

🔒 Sketch
 function storageConfiguration() {
+  const secretKey = process.env.OBJECT_STORAGE_SECRET_KEY;
+  if (!secretKey && process.env.NODE_ENV === "production") {
+    throw new Error("OBJECT_STORAGE_SECRET_KEY is required");
+  }
   return {
     endpoint: process.env.OBJECT_STORAGE_ENDPOINT ?? "http://127.0.0.1:9000",
     bucket: process.env.OBJECT_STORAGE_BUCKET ?? "muster-evidence",
     region: process.env.OBJECT_STORAGE_REGION ?? "us-east-1",
     accessKey: process.env.OBJECT_STORAGE_ACCESS_KEY ?? "muster",
-    secretKey: process.env.OBJECT_STORAGE_SECRET_KEY ?? "local-minio-secret",
+    secretKey: secretKey ?? "local-minio-secret",
   };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/evidence/src/object-storage.ts` around lines 118 - 126, Update
storageConfiguration to require OBJECT_STORAGE_ACCESS_KEY and
OBJECT_STORAGE_SECRET_KEY when running outside the explicitly local profile,
rather than falling back to committed development credentials; preserve the
local defaults only for that profile and ensure missing production credentials
fail fast during configuration.
packages/database/src/synthetic-cleanup.ts-140-164 (1)

140-164: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

localeCompare makes canonicalisation locale/ICU dependent, so digests are not portable.

canonical() sorts object keys with localeCompare and syntheticCleanupTableDigest sorts rows the same way. String.prototype.localeCompare depends on the runtime's default locale and ICU version, so a manifest captured by apps/web can hash differently in the worker or in another container image/locale — and the mismatch surfaces as "digest mismatch"/"pre-state digest changed" on an otherwise valid manifest. Digest input ordering must be deterministic (code-unit comparison), independent of environment. Note assertExactRows (Line 260) already sorts IDs with the default code-unit Array#sort, so the two orderings can also disagree with each other today.

🔒 Proposed fix
+function compare(left: string, right: string) {
+  return left < right ? -1 : left > right ? 1 : 0;
+}
+
 function canonical(value: unknown): string {
   if (value instanceof Date) return JSON.stringify(value.toISOString());
   if (value === null || typeof value !== "object") return JSON.stringify(value);
   if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
   return `{${Object.entries(value as Record<string, unknown>)
-    .sort(([a], [b]) => a.localeCompare(b))
+    .sort(([a], [b]) => compare(a, b))
     .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`)
     .join(",")}}`;
 }
@@
 export function syntheticCleanupTableDigest(rows: ReadonlyArray<CandidateRow>) {
   return sha256(
-    canonical([...rows].sort((left, right) => left.id.localeCompare(right.id))),
+    canonical([...rows].sort((left, right) => compare(left.id, right.id))),
   );
 }

objectInventoryDigest (Line 1362) needs the same change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/src/synthetic-cleanup.ts` around lines 140 - 164, Replace
locale-dependent localeCompare ordering with deterministic code-unit ordering in
canonical object-key sorting and syntheticCleanupTableDigest row sorting; use
the same default Array#sort ordering already used by assertExactRows. Apply the
equivalent ordering change to objectInventoryDigest as well, preserving all
existing digest inputs and hashing behavior.
packages/evidence/src/object-storage.ts-152-297 (1)

152-297: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

No timeout or abort signal on any of the object-storage fetch calls.

putObject, getObject, headObject, getObjectVersion and deleteObject all issue unbounded requests to an external endpoint. A stalled MinIO/S3 connection pins the calling request or worker task indefinitely — and these run inside the synthetic-cleanup deletion path and evidence upload path. checkObjectStorage already threads an AbortSignal; the same treatment (or AbortSignal.timeout(...)) is needed here.

🛡️ Sketch
+const requestTimeoutMs = Number(
+  process.env.OBJECT_STORAGE_TIMEOUT_MS ?? 10_000,
+);
+
 export const defaultObjectStorage: CleanupObjectStorage = {
   async putObject(object) {
@@
     const response = await fetch(url, {
       method: "PUT",
       headers: signingHeaders(/* … */),
       body: Buffer.from(object.body),
+      signal: AbortSignal.timeout(requestTimeoutMs),
     });

Apply the same signal to the remaining four operations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/evidence/src/object-storage.ts` around lines 152 - 297, Update the
fetch calls in defaultObjectStorage.putObject, getObject, headObject,
getObjectVersion, and deleteObject to include a bounded AbortSignal, reusing the
existing object-storage timeout or signal pattern established by
checkObjectStorage. Ensure every external request is cancellable and cannot
remain stalled indefinitely, while preserving the current request behavior and
error handling.
🟡 Minor comments (10)
docs/synthetic-cleanup.md-12-12 (1)

12-12: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify the cleanup-proof wording.

Replace “are never cleanup proof” with “are never proof of cleanup” to make the safety requirement unambiguous.

Proposed fix
-  idempotency-key text are never cleanup proof. Record provenance when a seed,
+  idempotency-key text are never proof of cleanup. Record provenance when a seed,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/synthetic-cleanup.md` at line 12, In the cleanup-proof wording within
the synthetic cleanup documentation, replace “are never cleanup proof” with “are
never proof of cleanup,” preserving the surrounding provenance and safety
guidance unchanged.

Source: Linters/SAST tools

apps/web/components/slack-settings-view.tsx-592-605 (1)

592-605: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Kill-switch link points at an arbitrary agent.

settings.agents[0] is whichever agent the settings query happened to return first, so the "kill switch" link sends the operator to an unrelated agent's learning page. Link to the agents index instead, or render the link per exposure row where the agent is known.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/components/slack-settings-view.tsx` around lines 592 - 605, Update
the emergency execution control text in the settings view so its kill-switch
link targets the agents index rather than using the arbitrary settings.agents[0]
agent. Remove the conditional agent-specific href while preserving the existing
fallback text and link styling.
apps/web/app/api/v1/slack/oauth/callback/route.ts-45-49 (1)

45-49: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fail fast when SLACK_REDIRECT_URI is unset.

Passing "" sends a mismatched redirect_uri to Slack and surfaces as an opaque OAuth parse failure. Throw the same "Slack OAuth is not configured" error the install route uses.

🛠️ Proposed fix
+    const redirectUri = process.env.SLACK_REDIRECT_URI;
+    if (!redirectUri) throw new Error("Slack OAuth is not configured");
     const installation = await slack.install(
       subject,
       code,
-      process.env.SLACK_REDIRECT_URI ?? "",
+      redirectUri,
     );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/api/v1/slack/oauth/callback/route.ts` around lines 45 - 49,
Update the Slack OAuth callback flow around slack.install to validate
SLACK_REDIRECT_URI before invoking the installation. When the environment
variable is unset, fail immediately with the same “Slack OAuth is not
configured” error and behavior used by the install route; otherwise pass the
configured URI unchanged instead of defaulting to an empty string.
scripts/install-homelab.sh-219-226 (1)

219-226: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Unescaped admin email/password in JSON payload.

admin_password/admin_email are interpolated directly into a hand-built JSON string. A custom MUSTER_LOCAL_ADMIN_PASSWORD containing " or \ breaks the payload and fails the automated bootstrap signup.

🐛 Proposed fix
+signup_payload="$(jq -n --arg email "$admin_email" --arg password "$admin_password" \
+  '{name: "Muster Administrator", email: $email, password: $password}')"
 signup_status="$(
   curl --silent \
     --output /dev/null \
     --write-out '%{http_code}' \
     -H "content-type: application/json" \
-    -d "{\"name\":\"Muster Administrator\",\"email\":\"${admin_email}\",\"password\":\"${admin_password}\"}" \
+    -d "$signup_payload" \
     "${base_url}/api/auth/sign-up/email"
 )"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install-homelab.sh` around lines 219 - 226, The sign-up request in
the signup_status curl block builds invalid JSON when admin_email or
admin_password contains JSON-special characters. Replace the hand-built payload
with safe JSON serialization using the script’s available tooling, ensuring both
values are escaped correctly while preserving the existing request fields and
endpoint.
scripts/install-homelab.sh-84-106 (1)

84-106: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Stale AUTH_TRUSTED_ORIGINS after changing MUSTER_PUBLIC_URL alone.

If .env.homelab already exists and a rerun sets a new MUSTER_PUBLIC_URL without also setting AUTH_TRUSTED_ORIGINS, the persisted existing_origins is reused verbatim (lines 102-103) and may not include the new public URL, breaking Better Auth on the new origin.

🐛 Proposed fix
 elif [[ "$environment_created" == "false" && -n "$existing_origins" ]]; then
-  requested_origins="$existing_origins"
+  if [[ ",$existing_origins," == *",$requested_public_url,"* ]]; then
+    requested_origins="$existing_origins"
+  else
+    requested_origins="${existing_origins},${requested_public_url}"
+  fi
 else
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install-homelab.sh` around lines 84 - 106, Update the
AUTH_TRUSTED_ORIGINS selection logic near requested_origins so changing
MUSTER_PUBLIC_URL causes the generated origins to use the new
requested_public_url instead of blindly reusing existing_origins. Preserve
explicitly provided AUTH_TRUSTED_ORIGINS, and retain existing persisted origins
when MUSTER_PUBLIC_URL was not overridden.
.github/workflows/ci.yml-211-213 (1)

211-213: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"Verify container starts" doesn't actually start anything.

The step is named to verify the container starts, but only runs docker image inspect (a metadata check on the already-loaded image), which cannot catch an entrypoint crash or startup failure.

♻️ Proposed fix
       - name: Verify container starts
         if: github.event_name == 'pull_request'
-        run: docker image inspect "${{ steps.image.outputs.build_ref }}"
+        run: |
+          docker run --rm --detach --name muster-pr-smoke "${{ steps.image.outputs.build_ref }}"
+          sleep 5
+          docker logs muster-pr-smoke
+          docker inspect --format '{{.State.Running}}' muster-pr-smoke | grep -x true
+          docker rm --force muster-pr-smoke
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 211 - 213, Update the “Verify
container starts” workflow step to actually launch the built image, replacing
the metadata-only docker image inspect check with a command that runs the image
and fails when its entrypoint cannot start. Preserve the existing pull_request
condition and image reference from steps.image.outputs.build_ref.
tests/homelab/install-homelab.test.sh-17-37 (1)

17-37: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the openssl stub return distinct values per call

The curl stub matches the installer’s --write-out usage, but the openssl stub always returns the same synthetic--hex value for every openssl rand -hex ... call. That can hide bugs that depend on separate generated secrets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/homelab/install-homelab.test.sh` around lines 17 - 37, Update the
openssl stub created in the test setup so each rand -hex invocation returns a
distinct synthetic value, rather than deriving the same output from the argument
alone. Preserve the existing command logging behavior and ensure repeated calls
generate distinguishable secrets for installer validation.
packages/database/src/synthetic-cleanup.ts-1973-1993 (1)

1973-1993: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Serialization-failure detection only inspects error.cause.code.

Depending on how the driver error propagates through the Drizzle transaction wrapper, 40001 can appear on the error itself rather than on cause. When it does, a concurrent apply that actually committed a receipt surfaces as a raw serialization error to the caller instead of the idempotent applied: false result the concurrent-apply integration test relies on. Check both.

🐛 Proposed fix
-    const code =
-      cause && typeof cause === "object" && "code" in cause
-        ? (cause as { code?: unknown }).code
-        : undefined;
+    const codeOf = (value: unknown) =>
+      value && typeof value === "object" && "code" in value
+        ? (value as { code?: unknown }).code
+        : undefined;
+    const code = codeOf(cause) ?? codeOf(error);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/src/synthetic-cleanup.ts` around lines 1973 - 1993, Update
the catch block around the synthetic cleanup transaction to detect serialization
code "40001" from either the top-level error or its cause, while preserving the
existing receipt lookup and idempotent applied:false return. Only rethrow when
neither location contains that code.
apps/web/lib/alfie-research-domain.ts-93-97 (1)

93-97: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exclude archived watchlists from the name conflict
list() hides archived rows, but create() still conflicts on the plain (organisationId, name) unique index. An archived watchlist will keep returning a misleading 409 for a name users can no longer see or reuse. apps/web/lib/alfie-research-domain.ts:87-146

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/alfie-research-domain.ts` around lines 93 - 97, Update the
watchlist name-conflict query in create() to exclude archived rows by applying
the same archivedAt-is-null condition used by list(). Ensure archived watchlists
do not trigger the organisationId/name conflict, while active watchlists retain
the existing 409 behavior.
packages/integrations/src/governed.ts-75-87 (1)

75-87: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Add a test for the custom CA pinning path
packages/integrations/src/governed.test.ts should cover a request with tlsCaCertificateBase64 so the ca option on pinnedJsonRequest is exercised.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/integrations/src/governed.ts` around lines 75 - 87, Add a test in
governed.test.ts covering a governed request configured with
tlsCaCertificateBase64, and assert that pinnedJsonRequest receives the decoded
certificate through its ca option. Reuse the existing request and mocking
patterns in the test file.

Comment thread apps/worker/src/index.ts
Comment on lines +2152 to +2171

const slackSocketAbort = new AbortController();
let slackSocketTask: Promise<void> | undefined;
if (process.env.SLACK_SOCKET_MODE_ENABLED === "true") {
const appToken = process.env.SLACK_APP_TOKEN;
if (!appToken)
throw new Error(
"SLACK_APP_TOKEN is required when SLACK_SOCKET_MODE_ENABLED=true",
);
const adapter = new SlackGovernanceAdapter();
slackSocketTask = runSlackSocketMode({
appToken,
signal: slackSocketAbort.signal,
recordEnvelope: (envelope) => adapter.recordSocketEnvelope(envelope),
onError: (error) =>
jsonLog("error", "slack.socket.error", {
error: error instanceof Error ? error.message : "Socket Mode failed",
}),
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Unhandled rejection: nothing observes slackSocketTask until shutdown.

runSlackSocketMode rejects on a non-xapp- token (and on any escape from its loop), but the returned promise has no rejection handler attached at creation time, so Node raises an unhandled rejection and terminates the worker. The same promise is then awaited unguarded during shutdown (Line 2211), where a rejection aborts the remaining worker/queue/database cleanup.

🛡️ Proposed fix
-  slackSocketTask = runSlackSocketMode({
+  slackSocketTask = runSlackSocketMode({
     appToken,
     signal: slackSocketAbort.signal,
     recordEnvelope: (envelope) => adapter.recordSocketEnvelope(envelope),
     onError: (error) =>
       jsonLog("error", "slack.socket.error", {
         error: error instanceof Error ? error.message : "Socket Mode failed",
       }),
-  });
+  }).catch((error: unknown) => {
+    jsonLog("error", "slack.socket.stopped", {
+      error: error instanceof Error ? error.message : "Socket Mode failed",
+    });
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const slackSocketAbort = new AbortController();
let slackSocketTask: Promise<void> | undefined;
if (process.env.SLACK_SOCKET_MODE_ENABLED === "true") {
const appToken = process.env.SLACK_APP_TOKEN;
if (!appToken)
throw new Error(
"SLACK_APP_TOKEN is required when SLACK_SOCKET_MODE_ENABLED=true",
);
const adapter = new SlackGovernanceAdapter();
slackSocketTask = runSlackSocketMode({
appToken,
signal: slackSocketAbort.signal,
recordEnvelope: (envelope) => adapter.recordSocketEnvelope(envelope),
onError: (error) =>
jsonLog("error", "slack.socket.error", {
error: error instanceof Error ? error.message : "Socket Mode failed",
}),
});
}
const slackSocketAbort = new AbortController();
let slackSocketTask: Promise<void> | undefined;
if (process.env.SLACK_SOCKET_MODE_ENABLED === "true") {
const appToken = process.env.SLACK_APP_TOKEN;
if (!appToken)
throw new Error(
"SLACK_APP_TOKEN is required when SLACK_SOCKET_MODE_ENABLED=true",
);
const adapter = new SlackGovernanceAdapter();
slackSocketTask = runSlackSocketMode({
appToken,
signal: slackSocketAbort.signal,
recordEnvelope: (envelope) => adapter.recordSocketEnvelope(envelope),
onError: (error) =>
jsonLog("error", "slack.socket.error", {
error: error instanceof Error ? error.message : "Socket Mode failed",
}),
}).catch((error: unknown) => {
jsonLog("error", "slack.socket.stopped", {
error: error instanceof Error ? error.message : "Socket Mode failed",
});
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/index.ts` around lines 2152 - 2171, Attach an immediate
rejection handler to the promise created by runSlackSocketMode in the
slackSocketTask initialization, logging the failure without creating an
unhandled rejection; retain the task for shutdown tracking. Update shutdown’s
await of slackSocketTask to catch or otherwise contain rejection so cleanup
continues even when the socket loop fails.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants